feat(auth): multi-session multi-profile support (client + SSR) - #14875
feat(auth): multi-session multi-profile support (client + SSR)#14875bobbor wants to merge 5 commits into
Conversation
Add userSignedIn, switchActiveUser, and userSignedOut events to AuthHubEventData, and add an optional user payload to signedOut and tokenRefresh. Supports the multi-session boundary event model.
Introduce an AuthUserList session roster (active user first) alongside LastAuthUser, and add setCurrentUser and listCurrentUsers. Sign-in/out now emit boundary Hub events (userSignedIn/switchActiveUser/ userSignedOut; signedIn/signedOut only at roster empty<->non-empty edges). Adds per-user token clearing, credential-cache busting on switch, and the createAuthSessionSwitcher primitive.
Add server variants of setCurrentUser and listCurrentUsers that accept a contextSpec and operate on the per-request (cookie-backed) token store. Reachability is via a minimal, non-destructive AuthSessionSwitcher (read + validated reorder only) surfaced by createUserPoolsTokenProvider and reached through a new additive AuthClass.getTokenProvider accessor. No destructive token operation crosses the server context boundary.
🦋 Changeset detectedLatest commit: 2c52b05 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const resolvedUsers = await Promise.all( | ||
| roster.map(async rosterUsername => { | ||
| try { | ||
| const idTokenKey = `${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken`; |
There was a problem hiding this comment.
[major] This constructs the idToken storage key by hand (${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken) instead of delegating to authTokenStore.getStoredIdToken(rosterUsername), which already encapsulates exactly this key logic via getAuthKeys. The server path in apis/server/listCurrentUsers.ts correctly uses switcher.getStoredIdToken(). If the key schema ever changes, this client path will silently diverge.
Replace the manual key construction + raw getItem + decodeJWT block with:
const idToken = await authTokenStore.getStoredIdToken(rosterUsername);
if (!idToken) return undefined;
const { 'cognito:username': cognitoUsername, sub } = idToken.payload ?? {};This also removes the need for the inner try/catch and aligns the two paths perfectly.
There was a problem hiding this comment.
Good catch 👍 — this was exactly the drift the server path was built to avoid. Switched to authTokenStore.getStoredIdToken(), dropped the manual key + decodeJWT + inner try/catch. Fixed in 2c52b05.
| await clearCredentials(); | ||
|
|
||
| // Resolve the now-active user for the event payload. | ||
| const currentUser = await getCurrentUser(Amplify); |
There was a problem hiding this comment.
[minor] getCurrentUser(Amplify) goes through TokenOrchestrator.getTokens(), which can trigger a token refresh if the newly-active user's access token is expired. That's a surprising side-effect for what should be a cheap pointer move. dispatchSignOutBoundaryEvents handles the identical identity-resolution problem correctly by using getStoredIdToken() — this should do the same:
const idToken = await tokenStore.getStoredIdToken(username);
const userId = (idToken?.payload?.sub as string) ?? '';
Hub.dispatch('auth', { event: 'switchActiveUser', data: { username, userId } }, 'Auth', AMPLIFY_SYMBOL);This also removes the getCurrentUser import dependency from this file.
There was a problem hiding this comment.
Agreed, the refresh side-effect was surprising. Now resolves from stored tokens via getStoredIdToken() (mirroring dispatchSignOutBoundaryEvents), and skips the dispatch entirely if the identity can't be resolved — no more getCurrentUser import. Fixed in 2c52b05.
| this.getAuthUserListKey(), | ||
| list.join(','), | ||
| ); | ||
| await this.getKeyValueStorage().setItem(this.getLastAuthUserKey(), list[0]); |
There was a problem hiding this comment.
[minor] AuthUserList and LastAuthUser are written sequentially here. A crash between the two leaves them out of sync (AuthUserList = bob,alice but LastAuthUser = alice from the previous write). The delete path has the right ordering comment, but the write path has the same race in the other direction. Since getAuthUserList() already treats AuthUserList as authoritative when present, worth adding an explicit comment that LastAuthUser here is best-effort / compatibility-only and doesn't affect roster correctness if the write is lost — otherwise the two-write sequence looks like an unguarded bug.
There was a problem hiding this comment.
Added the clarifying comment — AuthUserList is authoritative (getAuthUserList prefers it), LastAuthUser is best-effort compat only, so a lost second write doesn't affect roster correctness. Fixed in 2c52b05.
| ); | ||
| if (legacyLastAuthUser && legacyLastAuthUser !== 'username') { | ||
| const migratedList = [legacyLastAuthUser]; | ||
| await this.persistAuthUserList(migratedList); |
There was a problem hiding this comment.
[minor] getAuthUserList is a read path, but on first invocation after upgrade it calls persistAuthUserList — a write. So listCurrentUsers (read-only by contract) silently mutates storage on its first call. In SSR with ephemeral or read-only storage this write will throw and break the read. Worth wrapping the migration write in a try/catch so that a storage failure during migration degrades gracefully rather than preventing the list from being returned.
There was a problem hiding this comment.
Nice edge case 👍 — wrapped the migration persist in try/catch; on read-only storage the read still returns the migrated list, persistence just retries next time. Test added. Fixed in 2c52b05.
| // drive a refresh) must not be invoked. | ||
| expect(loadTokensSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[nit] No test covers the signInDetails branch (the stored signInDetails key being present and populated on the returned AuthUser). Worth adding one case — the path is a distinct storage read that can fail independently of the idToken read.
There was a problem hiding this comment.
Added — one test with stored signInDetails populated on the returned AuthUser, one where the read fails and the user is still returned without it. Fixed in 2c52b05.
- listCurrentUsers (client): resolve idToken via getStoredIdToken instead of hand-building the storage key - setCurrentUser: resolve switchActiveUser payload from stored tokens (no refresh side-effect); skip dispatch when identity unresolvable - dispatchSignOutHubEvents: skip switchActiveUser instead of emitting an empty userId - TokenStore: make legacy-roster migration write fail-safe on read-only storage; document LastAuthUser as best-effort compat - server listCurrentUsers: document missing signInDetails in JSDoc - tests for the signInDetails branch and updated paths
|
@soberm thanks for the thorough review! All 7 comments addressed in 2c52b05 — the major (hand-built storage key in client |
Description
Adds multi-session / multi-profile support to Cognito auth: multiple users can be signed in to the same user pool simultaneously, with one active session at a time and the others parked. Works both client-side and in SSR (HttpOnly cookies).
New public APIs
setCurrentUser(username)— switch the active session to an already-signed-in user (throws if not signed in). Client + server.listCurrentUsers(): Promise<AuthUser[]>— list all signed-in users, active first. Client + server.Server variants live under
aws-amplify/auth/serverand accept acontextSpec, operating on the per-request cookie-backed token store.Storage model
AuthUserListkey holds a comma-separated, ordered roster (active first), kept alongside the existingLastAuthUser(which mirrorsAuthUserList[0]for cross-SDK compatibility). Per-user token namespaces are unchanged.Hub events (boundary model)
userSignedIn,switchActiveUser,userSignedOut(per-session roster membership / active-pointer moves).signedIn/signedOutnow fire only at the empty↔non-empty roster edges; payloads unchanged ({ username, userId }), with an optional user payload added tosignedOut/tokenRefresh. Backward compatible for existing single-session apps.Server-side safety
Server exposure is deliberately minimal and non-destructive: the per-request token provider surfaces only a narrow
AuthSessionSwitcher(read + validated reorder). Destructive token-store operations (storeTokens,clearTokens,clearTokensForUser,removeSession) never cross the server context boundary. Reachability is via a new additiveAuthClass.getTokenProvider()accessor; core's genericTokenProviderinterface is unchanged.Commits
feat(core): add multi-session auth Hub eventsfeat(auth): add client-side multi-session supportfeat(auth): expose multi-session APIs for server-side renderingTesting
yarn buildclean across@aws-amplify/auth,aws-amplify,@aws-amplify/adapter-nextjs.@aws-amplify/auth: full unit suite green (1193+ tests), incl. new tests for the roster, boundary events,setCurrentUser/listCurrentUsers(client + server), and the session switcher.aws-amplify: 51/51 incl. the API-surfaceexportsguard (updated for the intended new symbols only).yarn lintclean.Amplify.getConfig), per repo convention.Notes
adapter-nextjsrequired no change — the server APIs are callable throughrunWithAmplifyServerContextlikegetCurrentUser.Checklist